React useCallbackの実務的な使い方|パフォーマンス最適化とメモ化戦略
useCallbackとは|簡易的な解説
ReactのuseCallbackは、関数をメモ化(保存)するHooksです。毎回レンダリングのたびに新しい関数を生成するのではなく、依存配列が変わるまで同じ関数参照を保持します。
実務では「なぜ必要なのか」という視点が重要です。JavaScriptでは関数もオブジェクトなので、毎回新しく生成されると参照が異なります。これが子コンポーネントへの不要な再レンダリングを引き起こすため、パフォーマンス最適化が必要になるわけです。
// useCallbackなしの問題パターン
function Parent() {
const [count, setCount] = useState(0);
// このhandleClickは毎回新しく生成される
const handleClick = () => {
console.log('clicked');
};
return <Child onClickHandler={handleClick} />;
}
// useCallbackで解決
function ParentFixed() {
const [count, setCount] = useState(0);
// 依存配列が変わるまで同じ関数参照を保持
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // 空の依存配列 = マウント時のみ生成
return <Child onClickHandler={handleClick} />;
}
業務でのユースケース|実務で本当に必要な場面
useCallbackが活躍するシーンは限定的です。むやみに使うとコード量が増えて可読性が低下するため、本当に必要な場面を見極めることが重要です。
ユースケース1:フォーム入力の検索API呼び出し
ユーザーの入力を監視して検索APIを呼び出すパターンは実務で頻出です。入力のたびに新しい関数が生成されると、useEffectが不要に発火して無駄なAPI呼び出しが増えます。
// 実務例:検索フォームのAPI呼び出し
interface SearchParams {
keyword: string;
category: string;
}
function SearchForm() {
const [params, setParams] = useState<SearchParams>({
keyword: '',
category: 'all'
});
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
// fetchSearchはAPIコールするための関数
const fetchSearch = useCallback(async (searchParams: SearchParams) => {
if (!searchParams.keyword.trim()) {
setResults([]);
return;
}
setLoading(true);
try {
const response = await fetch(
`/api/search?keyword=${searchParams.keyword}&category=${searchParams.category}`
);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
} finally {
setLoading(false);
}
}, []); // 依存配列は空 = 関数は常に同じ
// useEffectはfetchSearchの参照が変わるまで再実行されない
useEffect(() => {
const timer = setTimeout(() => {
fetchSearch(params);
}, 300); // デバウンス処理
return () => clearTimeout(timer);
}, [params, fetchSearch]);
return (
<div>
<input
type=\"text\"
value={params.keyword}
onChange={(e) =>
setParams({ ...params, keyword: e.target.value })
}
placeholder=\"検索キーワード\"
/>
{loading && <p>検索中...</p>}
<div>
{results.map((result) => (
<div key={result.id}>{result.title}</div>
))}
</div>
</div>
);
}
ユースケース2:Redux dispatches の最適化
Redux を使う場合、dispatch 関数をコンポーネントプロップとして渡すシーンがあります。この場合 useCallback を使うと、不要な再レンダリングを防げます。
// Redux接続の実例
import { useDispatch, useSelector } from 'react-redux';
import { fetchUserData, updateUserProfile } from './userSlice';
function UserProfile() {
const dispatch = useDispatch();
const user = useSelector((state) => state.user);
// dispatchをメモ化することで、子コンポーネントの不要な再レンダリング防止
const handleFetchUser = useCallback(
(userId: string) => {
dispatch(fetchUserData(userId));
},
[dispatch]
);
const handleUpdateProfile = useCallback(
(data: UserData) => {
dispatch(updateUserProfile(data));
},
[dispatch]
);
return (
<>
<UserHeader user={user} onFetch={handleFetchUser} />
<UserForm user={user} onUpdate={handleUpdateProfile} />
</>
);
}
ユースケース3:複雑なイベントハンドラの共有
複数の子コンポーネントで同じロジックが必要な場合、親で定義した関数をメモ化して渡します。
// 複数のダイアログが同じ削除ロジックを使うケース
interface DialogItem {
id: string;
name: string;
}
function ItemManager() {
const [items, setItems] = useState<DialogItem[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// 削除処理をメモ化
const handleDelete = useCallback((id: string) => {
setItems((prevItems) => prevItems.filter((item) => item.id !== id));
setSelectedIds((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}, []);
// 一括削除もメモ化
const handleBulkDelete = useCallback(() => {
const idsToDelete = Array.from(selectedIds);
setItems((prevItems) =>
prevItems.filter((item) => !idsToDelete.includes(item.id))
);
setSelectedIds(new Set());
}, [selectedIds]);
return (
<>
<ItemList items={items} onDelete={handleDelete} />
<BulkDeleteButton
selectedCount={selectedIds.size}
onBulkDelete={handleBulkDelete}
/>
</>
);
}
実装コード|実務で使える完全な例
ここでは、実際の業務でそのまま応用できる例を示します。
フルスタック実装:ユーザー管理画面
// types.ts
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: string;
}
interface UserFilters {
role: string;
searchKeyword: string;
sortBy: 'name' | 'createdAt';
}
// UserManagement.tsx
import React, { useState, useCallback, useEffect } from 'react';
function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [filters, setFilters] = useState<UserFilters>({
role: 'all',
searchKeyword: '',
sortBy: 'name'
});
const [loading, setLoading] = useState(false);
// APIからユーザー取得
const loadUsers = useCallback(async (userFilters: UserFilters) => {
setLoading(true);
try {
const queryParams = new URLSearchParams({
role: userFilters.role !== 'all' ? userFilters.role : '',
keyword: userFilters.searchKeyword,
sort: userFilters.sortBy
});
const response = await fetch(`/api/users?${queryParams}`);
const data = await response.json();
setUsers(data);
} catch (error) {
console.error('Failed to load users:', error);
} finally {
setLoading(false);
}
}, []);
// ユーザー削除
const handleDeleteUser = useCallback((userId: string) => {
if (!window.confirm('このユーザーを削除しますか?')) {
return;
}
setUsers((prevUsers) =>
prevUsers.filter((user) => user.id !== userId)
);
// サーバーに削除リクエスト(実務ではエラーハンドリング必須)
fetch(`/api/users/${userId}`, { method: 'DELETE' }).catch(() => {
// ロールバック処理
loadUsers(filters);
});
}, [filters, loadUsers]);
// ロール変更
const handleChangeRole = useCallback(
(userId: string, newRole: 'admin' | 'user') => {
const originalUsers = users;
setUsers((prevUsers) =>
prevUsers.map((user) =>
user.id === userId ? { ...user, role: newRole } : user
)
);
fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole })
}).catch(() => {
setUsers(originalUsers);
alert('ロール変更に失敗しました');
});
},
[users]
);
// フィルター変更時にデータを再取得
useEffect(() => {
loadUsers(filters);
}, [filters, loadUsers]);
return (
<div className=\"user-management\">
<h1>ユーザー管理</h1>
<UserFilters
filters={filters}
onFilterChange={setFilters}
/>
{loading ? (
<p>読込中...</p>
) : (
<UserTable
users={users}
onDelete={handleDeleteUser}
onChangeRole={handleChangeRole}
/>
)}
</div>
);
}
// UserTable.tsx - メモ化されたコンポーネント
interface UserTableProps {
users: User[];
onDelete: (userId: string) => void;
onChangeRole: (userId: string, role: 'admin' | 'user') => void;
}
const UserTable = React.memo(function UserTable({
users,
onDelete,
onChangeRole
}: UserTableProps) {
return (
<table>
<thead>
<tr>
<th>名前</th>
<th>メール</th>
<th>ロール</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<UserRow
key={user.id}
user={user}
onDelete={onDelete}
onChangeRole={onChangeRole}
/>
))}
</tbody>
</table>
);
});
// UserRow.tsx - さらに細かくメモ化
interface UserRowProps {
user: User;
onDelete: (userId: string) => void;
onChangeRole: (userId: string, role: 'admin' | 'user') => void;
}
const UserRow = React.memo(function UserRow({
user,
onDelete,
onChangeRole
}: UserRowProps) {
const handleRoleToggle = useCallback(() => {
const newRole = user.role === 'admin' ? 'user' : 'admin';
onChangeRole(user.id, newRole);
}, [user.id, user.role, onChangeRole]);
const handleDelete = useCallback(() => {
onDelete(user.id);
}, [user.id, onDelete]);
return (
<tr>
<td>{user.name}</td>
<td>{user.email}</td>
<td>
<button onClick={handleRoleToggle}>
{user.role === 'admin' ? 'Admin→User' : 'User→Admin'}
</button>
</td>
<td>
<button onClick={handleDelete}>削除</button>
</td>
</tr>
);
});
よくある応用パターン
パターン1:useCallbackチェーン
複数の関数が依存し合う場合、依存関係を正しく設定することが重要です。
function DataProcessor() {
const [data, setData] = useState([]);
const [processed, setProcessed] = useState([]);
// 基本的な処理
const processData = useCallback((rawData) => {
return rawData.map((item) => ({
...item,
processed: true
}));
}, []);
// processDataに依存する別の処理
const applyFilters = useCallback(
(rawData, filters) => {
const processed = processData(rawData);
return processed.filter((item) =>
filters.every((filter) => filter(item))
);
},
[processData] // processDataを依存配列に含める
);
// applyFiltersに依存する最終処理
const generateReport = useCallback(
(rawData, filters) => {
const filtered = applyFilters(rawData, filters);
return {
total: filtered.length,
items: filtered
};
},
[applyFilters] // applyFiltersを依存配列に含める
);
return null;
}
パターン2:useCallbackと useRef の組み合わせ
前の値と比較する必要がある場合、useRef を組み合わせます。
function PaginatedList() {
const [page, setPage] = useState(1);
const previousPageRef = useRef(1);
const handlePageChange = useCallback((newPage: number) => {
const previousPage = previousPageRef.current;
previousPageRef.current = newPage;
// ページ遷移時に前のページへのスクロール位置を保存
if (newPage > previousPage) {
console.log('次へ移動');
} else {
console.log('前へ戻る');
}
setPage(newPage);
}, []);
return (
<button onClick={() => handlePageChange(page + 1)}>
次へ
</button>
);
}
パターン3:条件付きメモ化
開発環境でのデバッグのため、useCallback を条件付きで使う場合があります。
function ConditionalCallback() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
// または
const IS_DEV = process.env.NODE_ENV === 'development';
const handleClickConditional = IS_DEV
? () => {
console.log('clicked (no memoization in dev)');
}
: useCallback(() => {
console.log('clicked (memoized in prod)');
}, []);
return null;
}
注意点と落とし穴
注意点1:過度なメモ化
useCallback を使うこと自体にコストがかかります。シンプルな関数であれば、毎回生成する方が高速なこともあります。
// ❌ 悪い例:シンプルすぎる関数をメモ化
function BadExample() {
const handleClick = useCallback(() => {
console.log('click');
}, []);
return <button onClick={handleClick}>Click</button>;
}
// ✅ 良い例:複雑な処理や子コンポーネント最適化が必要な場合のみ
function GoodExample() {
const [items, setItems] = useState([]);
const handleAdd = useCallback((newItem) => {
setItems((prevItems) => {
// 複雑なロジック
const processed = complexProcess(newItem);
return [...prevItems, processed];
});
}, []);
return <ExpensiveChildComponent onAdd={handleAdd} />;
}
注意点2:依存配列の誤設定
依存配列を空にしすぎると、古い値を参照し続けるバグが発生します。
// ❌ バグ:countの最新値が反映されない
function BuggyCounter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log(count); // 常に0
setCount(count + 1);
}, []); // countを依存配列に含めていない
return (
<>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</>
);
}
// ✅ 正しい例1:countを依存配列に含める
function FixedCounter1() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log(count);
setCount(count + 1);
}, [count]); // countを含める
return (
<>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</>
);
}
// ✅ 正しい例2:状態更新関数形式を使う(推奨)
function FixedCounter2() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount((prevCount) => prevCount + 1); // 前の値を参照
}, []); // countを含める必要がない
return (
<>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</>
);
}
注意点3:React.memo とのセット使い
useCallback は React.memo と組み合わせてはじめて効果を発揮します。単独では意味がありません。
// ❌ 非効率:useCallbackを使ってもchildが常に再レンダリング
function Inefficient() {
const handleClick = useCallback(() => {
console.log('click');
}, []);
return <Child onClick={handleClick} />;
}
function Child({ onClick }) {
return <button onClick={onClick}>Click</button>;
}
// ✅ 効率的:React.memoでchildをラップ
function Efficient() {
const handleClick = useCallback(() => {
console.log('click');
}, []);
return <MemoChild onClick={handleClick} />;
}
const MemoChild = React.memo(({ onClick }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});
注意点4:ESLint 警告への対処
react-hooks/exhaustive-deps ESLint ルールを無視してはいけません。警告が出る場合は、設計を見直すべきです。
// ❌ 危険:ESLint警告を無視している
const handleUpdate = useCallback(() => {
updateData(userId); // userIdに依存しているのに含めていない
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ✅ 正しい:警告に従う
const handleUpdate = useCallback(() => {
updateData(userId);
}, [userId]); // userIdを含める
// または
// ✅ 別の解決策:useRefで保持
const userIdRef = useRef(userId);
useEffect(() => {
userIdRef.current = userId;
}, [userId]);
const handleUpdate = useCallback(() => {
updateData(userIdRef.current);
}, []);
まとめ
React の useCallback は強力なツールですが、実務では慎重に使う必要があります。
実務での使い分け
- 使うべき場面:API 呼び出し、Redux dispatch、複数の子コンポーネントへの props 渡し、複雑なイベントハンドラ
- 使わなくてよい場面:シンプルな onClick ハンドラ、state の単純な更新、小規模コンポーネント
ベストプラクティス
- 必要性を問う:本当にパフォーマンス問題があるか確認してから使う
- React.memo と組み合わせる:useCallback 単独では効果がない
- 依存配列を正確に設定する:ESLint 警告に従う
- 状態更新関数を活用する:setState の更新関数形式で依存配列を減らす
- チームで一貫性を持たせる:どんな時に使うか、コード規約を決める
useCallback は「早すぎる最適化の悪い例」になりやすいため、パフォーマンスプロファイラで実際の問題を確認してから導入することが、実務での成功の鍵です。

